5.09. Рекомендации по разработке на Kotlin
Рекомендации по разработке на Kotlin
Введение в культуру кода Kotlin
Рекомендации по разработке формируют основу профессиональной практики. Они обеспечивают согласованность кодовой базы, упрощают совместную работу и снижают стоимость сопровождения. Kotlin как язык проектировался с учётом современных практик, поэтому многие рекомендации естественным образом вытекают из его синтаксиса и стандартной библиотеки. Следование этим правилам позволяет писать код, который читается как хорошо составленное предложение на естественном языке.
Соглашения об именовании
Основные принципы именования
Все идентификаторы в Kotlin используют осмысленные английские слова без сокращений, за исключением общепринятых аббревиатур вроде id, url, io. Имена передают назначение сущности и её роль в системе. Короткие имена допустимы только в узких контекстах: счётчики циклов (i, j), лямбда-параметры (it), временные переменные в небольших блоках.
Стили написания идентификаторов
| Сущность | Стиль | Пример |
|---|---|---|
| Классы, объекты, перечисления, аннотации | PascalCase | UserProfile, NetworkClient |
| Функции, свойства, переменные | camelCase | calculateTotal, isActive, userCount |
| Константы верхнего уровня и объектов | UPPER_SNAKE_CASE | MAX_RETRY_COUNT, DEFAULT_TIMEOUT_MS |
| Пакеты | строчные буквы, без подчёркиваний | com.example.data.repository |
| Параметры функций | camelCase | userId, requestBody |
Специфические правила именования
Имена функций начинаются с глагола или глагольной группы: loadUsers, validateInput, transformData. Для булевых свойств и функций используйте префиксы is, has, can, should: isEnabled, hasPermission, canEdit. Избегайте избыточных префиксов вроде m_ или s_ — область видимости и контекст делают их ненужными.
При именовании расширений (extension functions) учитывайте получателя: String.toUri() читается естественно, тогда как String.convertToUri() избыточен. Для операторных функций соблюдайте семантику оператора: plus для объединения, get для доступа по индексу.
Имена файлов совпадают с основным классом в файле или описывают содержимое, если файл содержит несколько классов или только функции верхнего уровня. Файлы с расширениями группируются по типу получателя: StringExtensions.kt, FlowExtensions.kt.
Форматирование кода
Отступы и переносы строк
Используйте четыре пробела для отступов. Табуляция запрещена. Открывающая фигурная скобка размещается в той же строке, что и объявление, за исключением аннотированных классов и интерфейсов:
fun process(data: List<String>): Result {
return data
.filter { it.isNotEmpty() }
.map { it.trim() }
.let { transform(it) }
}
class UserRepository @Inject constructor(
private val dataSource: DataSource,
private val cache: Cache
) : Repository {
// тело класса
}
При переносе аргументов функции или конструктора каждый параметр размещается на новой строке с выравниванием:
fun createUser(
firstName: String,
lastName: String,
email: String,
role: UserRole = UserRole.USER
): User {
// реализация
}
Пробелы и операторы
Разделяйте операторы пробелами: a + b, x > threshold, list.map { it * 2 }. Не ставьте пробелы внутри скобок: function(arg), а не function( arg ). После запятых и двоеточений в типах добавляйте пробел: List<String>, val name: String.
Для длинных цепочек вызовов каждый метод начинается с новой строки с точкой в начале:
val result = dataSource
.queryUsers()
.filter { it.isActive }
.sortedBy { it.registrationDate }
.map { UserView(it) }
.toList()
Пустые строки
Разделяйте логические блоки пустыми строками:
- Между свойствами и функциями в классе
- Между разными группами свойств (публичные, приватные)
- Перед и после вложенных классов и объектов
- Между операторами в сложных функциях
Не добавляйте пустые строки после открывающей скобки или перед закрывающей. Избегайте множественных пустых строк подряд — одна строка достаточна для визуального разделения.
Структура проекта и организация файлов
Стандартная структура модуля
src/
├── main/
│ ├── kotlin/
│ │ └── com/
│ │ └── example/
│ │ ├── app/ # точка входа, инициализация
│ │ ├── domain/ # бизнес-логика, сущности
│ │ ├── data/ # источники данных, репозитории
│ │ ├── ui/ # представление, экраны
│ │ └── di/ # конфигурация зависимостей
│ └── resources/
└── test/
└── kotlin/
└── com/
└── example/
├── domain/
├── data/
└── ui/
Принципы организации пакетов
Пакеты группируются по функциональной принадлежности, а не по типу артефакта. Предпочитайте feature.auth вместо разделения на models, views, controllers. Внутри функционального модуля допустимо разделение по слоям: auth.model, auth.repository, auth.viewmodel.
Каждый файл содержит одну основную сущность плюс связанные вспомогательные элементы. Функции расширения группируются в отдельные файлы по типу получателя. Файлы с утилитами сводятся к минимуму — предпочтительнее использовать расширения или внедрение зависимостей.
Проектирование классов и типов
Принцип единственной ответственности
Каждый класс решает одну задачу. Класс с именем, содержащим союз «и» (UserAndOrderManager), сигнализирует о нарушении этого принципа. Разделяйте такие классы на несколько узкоспециализированных компонентов.
Используйте специализированные типы Kotlin для разных сценариев:
data classдля хранения данных с автоматической реализациейequals,hashCode,toStringsealed classилиsealed interfaceдля ограниченных иерархий типовvalue classдля обёрток над примитивными типами без накладных расходовobjectдля синглтонов и утилит с внутренним состоянием
Свойства вместо геттеров и сеттеров
Предпочитайте свойства полям с ручными геттерами и сеттерами. Используйте делегаты свойств для стандартных паттернов:
class ViewModel {
private val _users = MutableLiveData<List<User>>()
val users: LiveData<List<User>> = _users
private var cachedData by lazy { loadData() }
private var count by Delegates.observable(0) { _, old, new ->
log("Count changed from $old to $new")
}
}
Для вычисляемых свойств без внутреннего состояния используйте val с кастомным геттером. Избегайте изменяемых свойств (var) без веской причины — неизменяемость упрощает рассуждение о коде.
Конструкторы и инициализация
Основной конструктор размещается в заголовке класса. Для сложной инициализации используйте вторичные конструкторы или фабричные функции верхнего уровня:
class User private constructor(
val id: UserId,
val name: String,
val email: Email
) {
companion object {
fun create(id: String, name: String, email: String): Result<User, Error> {
return when {
name.isBlank() -> Error.InvalidName
!email.contains('@') -> Error.InvalidEmail
else -> Success(User(UserId(id), name, Email(email)))
}
}
}
}
Инициализация объекта через apply или also предпочтительнее последовательного вызова сеттеров:
val request = HttpRequest.Builder()
.url("https://api.example.com")
.method(HttpMethod.POST)
.header("Content-Type", "application/json")
.body(jsonData)
.build()
Работа с функциями
Чистые функции и побочные эффекты
Чистые функции не изменяют внешнее состояние и возвращают один и тот же результат для одинаковых аргументов. Такие функции легко тестируются и комбинируются. Помечайте функции с побочными эффектами комментариями или используйте специальные типы-обёртки для явного указания эффектов.
Функции с побочными эффектами именуются глаголами действия: saveUser(), sendNotification(), logEvent(). Чистые функции могут использовать существительные или прилагательные: calculateTotal(), isValidEmail().
Параметры и аргументы по умолчанию
Используйте параметры по умолчанию вместо перегрузки функций:
fun formatCurrency(
amount: BigDecimal,
currency: Currency = Currency.USD,
locale: Locale = Locale.US,
showSymbol: Boolean = true
): String { /* реализация */ }
Для функций с множеством параметров применяйте именованные аргументы при вызове, особенно когда значения по умолчанию пропускаются:
formatCurrency(
amount = BigDecimal("1234.56"),
locale = Locale.FRANCE,
showSymbol = false
)
Функции высшего порядка и лямбды
Передавайте функции как параметры для расширения поведения без наследования. Для лямбд с одним параметром используйте it. Для нескольких параметров или сложной логики объявляйте именованные параметры:
list.filter { it.isActive }
.map { user -> UserSummary(user.id, user.name) }
data.retryOnFailure(times = 3) { attempt ->
log("Attempt $attempt")
fetchData()
}
Размещайте лямбды после закрывающей скобки, если это последний параметр функции. Для коротких лямбд допустим однострочный формат. Длинные лямбды оформляются как обычные блоки кода с отступами.
Обработка ошибок
Использование исключений
Исключения применяются для действительно исключительных ситуаций, нарушающих нормальный поток выполнения. Не используйте исключения для управления бизнес-логикой. Проверяемые исключения отсутствуют в Kotlin — вместо них применяйте типы Result или специализированные обёртки.
Для операций, которые могут завершиться неудачей, предоставляйте две версии функции:
- Бросающую исключение:
fun getUser(id: UserId): User - Возвращающую
Result:fun getUserOrNull(id: UserId): Result<User, Error>
Null safety как основа надёжности
Проектируйте API без использования null там, где это возможно. Используйте Optional-подобные типы (Result, Either) или коллекции для представления отсутствия значения. Когда null неизбежен, ограничивайте его область видимости и проверяйте как можно раньше.
Предпочитайте безопасные операторы и элвис-оператор явным проверкам:
val name = user?.profile?.name ?: "Anonymous"
val firstChar = name.firstOrNull() ?: 'A'
Избегайте оператора !! за исключением ситуаций, когда вы абсолютно уверены в ненулевом значении и готовы к падению приложения при нарушении инварианта.
Работа с коллекциями
Иммутабельность по умолчанию
Используйте неизменяемые коллекции (List, Map, Set) везде, где это возможно. Изменяемые коллекции (MutableList и другие) применяются только внутри реализации компонентов и не возвращаются наружу.
class ShoppingCart {
private val _items = mutableListOf<CartItem>()
val items: List<CartItem> get() = _items.toList()
fun addItem(item: CartItem) {
_items.add(item)
}
}
Цепочки преобразований
Стандартная библиотека Kotlin предоставляет богатый набор функций для работы с коллекциями. Комбинируйте их в цепочки для декларативного описания преобразований:
val activePremiumUsers = users
.filter { it.isActive }
.filter { it.subscription.isPremium }
.sortedByDescending { it.lastLoginDate }
.map { UserPreview(it.id, it.name, it.email) }
Для сложных преобразований выносите логику в отдельные функции с осмысленными именами вместо длинных цепочек в одном месте.
Последовательности для больших объёмов данных
При обработке больших коллекций используйте Sequence для ленивых вычислений:
val result = largeDataSet.asSequence()
.filter { it.meetsCriteria() }
.map { transform(it) }
.firstOrNull { it.isTarget() }
Последовательности избегают промежуточных аллокаций, характерных для операций над списками. Преобразуйте последовательность в коллекцию только в конце цепочки, когда требуется материализованный результат.
Асинхронное программирование с корутинами
Структура корутин
Каждая корутина должна иметь свой CoroutineScope с явно определённым жизненным циклом. Используйте viewModelScope в Android ViewModel, lifecycleScope в компонентах с жизненным циклом, или создавайте собственные скоупы с SupervisorJob для независимого завершения дочерних корутин.
class UserRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
fun loadUsers(): Job = scope.launch {
val users = withContext(ioDispatcher) {
api.fetchUsers()
}
cache.save(users)
}
fun close() {
scope.cancel()
}
}
Обработка ошибок в корутинах
Оборачивайте код в корутинах в try/catch или используйте runCatching. Для обработки ошибок на уровне всей корутины применяйте обработчики исключений через CoroutineExceptionHandler:
val handler = CoroutineExceptionHandler { _, exception ->
log.error("Coroutine failed", exception)
}
scope.launch(handler) {
// операции, которые могут завершиться ошибкой
}
Избегайте глобального перехвата всех исключений без логирования — это скрывает проблемы и затрудняет отладку.
Отмена корутин
Корутины должны корректно реагировать на отмену. Проверяйте isActive в длительных циклах и используйте приостанавливающие функции, поддерживающие отмену (delay, withTimeout). Для блокирующих операций применяйте withContext(NonCancellable) только когда отмена действительно невозможна или опасна.
Комментирование и документирование
Самодокументируемый код
Структура кода, имена и типы должны передавать основной смысл без комментариев. Комментарии объясняют «почему», а не «что» делает код. Избегайте комментариев, дублирующих код:
// Плохо: комментарий повторяет код
// Увеличиваем счётчик на единицу
counter += 1
// Хорошо: комментарий объясняет причину
// Счётчик увеличивается здесь, потому что операция может быть вызвана
// из нескольких мест, но должна учитываться только один раз
counter += 1
KDoc для публичного API
Все публичные классы, функции и свойства сопровождаются документацией в формате KDoc. Описание начинается с краткого предложения, затем развёрнутое объяснение при необходимости. Для параметров и возвращаемых значений используйте теги @param и @return:
/**
* Загружает профиль пользователя по идентификатору.
*
* Функция обращается к удалённому API и кэширует результат.
* При ошибке сети возвращает данные из кэша, если они доступны.
*
* @param userId уникальный идентификатор пользователя
* @param forceRefresh игнорировать кэш и загрузить свежие данные
* @return профиль пользователя или ошибку загрузки
*/
suspend fun loadUserProfile(
userId: UserId,
forceRefresh: Boolean = false
): Result<UserProfile, LoadError> { /* реализация */ }
Комментарии для временных решений
Временные решения и технический долг помечаются комментариями с указанием причины и срока устранения:
// ВРЕМЕННО: обход ошибки в библиотеке версии 2.3.1
// Убрать после обновления до 2.4.0 (планируется в декабре 2026)
val workaroundValue = rawData.replace("invalid", "valid")
Такие комментарии становятся точками внимания при планировании технического долга.
Практики обеспечения качества
Тестирование
Каждый публичный метод покрывается модульными тестами. Тесты группируются по поведению, а не по методам. Используйте выразительные имена тестовых функций:
@Test
fun `calculate total applies discount for premium users`() {
val cart = ShoppingCart(userType = PREMIUM)
cart.add(Item(price = 100))
cart.add(Item(price = 200))
assertEquals(270, cart.calculateTotal())
}
Для тестирования корутин применяйте runTest из kotlinx-coroutines-test. Мокируйте зависимости через интерфейсы, а не через конкретные классы.
Статический анализ
Проект настраивается с анализаторами:
ktlintдля форматирования и базовых проверок стиляdetektдля обнаружения потенциальных ошибок и нарушений архитектурыkotlinx-serializationдля безопасной сериализации
Конфигурация анализаторов сохраняется в репозитории и применяется на этапе сборки. Все предупреждения анализаторов исправляются до коммита — накопление предупреждений снижает их ценность.
Непрерывная интеграция
Каждый коммит проходит проверку в CI:
- Сборка проекта
- Запуск всех тестов
- Проверка стиля кода анализаторами
- Генерация отчётов о покрытии тестами
- Сборка артефактов для развёртывания
Запрещается мержить изменения, нарушающие сборку или тесты. Красная ветка в репозитории считается критической проблемой, требующей немедленного исправления.
Архитектурные рекомендации
Чистая архитектура и слои
Приложение разделяется на слои с односторонними зависимостями:
- Слой данных зависит от внешних источников (сеть, база данных)
- Доменный слой содержит бизнес-логику и не зависит от инфраструктуры
- Слой представления зависит от домена, но не от деталей реализации данных
Зависимости направлены внутрь — от инфраструктуры к бизнес-логике. Инверсия зависимостей достигается через интерфейсы, определённые в доменном слое и реализованные в слое данных.
Инъекция зависимостей
Конструкторная инъекция — основной способ передачи зависимостей. Избегайте сервис-локатора и статических зависимостей. Для конфигурации используйте фреймворки вроде Koin или Dagger/Hilt, но структурируйте код так, чтобы он оставался тестируемым без фреймворка.
class UserViewModel(
private val userRepository: UserRepository,
private val analytics: AnalyticsService
) : ViewModel() {
// реализация
}
Обработка состояния
Для управления состоянием в интерфейсе используйте однонаправленный поток данных:
- Событие от пользователя → действие (Action)
- Действие → изменение состояния через редьюсер
- Новое состояние → обновление интерфейса
Состояние хранится как неизменяемые объекты. Каждое изменение создаёт новую копию состояния. Для реактивного обновления применяются StateFlow или LiveData.
Инструменты и конфигурация
EditorConfig
Файл .editorconfig в корне проекта обеспечивает единообразное форматирование во всех средах разработки:
root = true
[*.{kt,kts}]
indent_size = 4
indent_style = space
max_line_length = 120
insert_final_newline = true
trim_trailing_whitespace = true
[*.{md,txt}]
max_line_length = off
Версионирование зависимостей
Все версии библиотек централизуются в libs.versions.toml или отдельном конфигурационном файле. Это упрощает обновление и гарантирует согласованность версий в мульти-модульных проектах.
Сборка и зависимости
Скрипты сборки пишутся на Kotlin DSL для лучшей типизации и поддержки. Зависимости группируются по назначению: реализация, тестирование, инструменты разработки. Транзитивные зависимости контролируются через исключения, чтобы избежать конфликтов версий.